Skip to content

Make Blob.FromStream GC safe#3473

Open
jeremy-visionaid wants to merge 3 commits into
mono:mainfrom
jeremy-visionaid:make-blob-from-stream-gc-safe
Open

Make Blob.FromStream GC safe#3473
jeremy-visionaid wants to merge 3 commits into
mono:mainfrom
jeremy-visionaid:make-blob-from-stream-gc-safe

Conversation

@jeremy-visionaid

Copy link
Copy Markdown
Contributor

Description of Change

Avoids a potential crash from memory being moved by GC

Bugs Fixed

API Changes

None.

Behavioral Changes

None.

Required skia PR

None.

PR Checklist

  • Has tests (if omitted, state reason in description)
  • Rebased on top of main at time of PR
  • Merged related skia PRs
  • Changes adhere to coding standard
  • Updated documentation

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Hey there @@jeremy-visionaid! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request fixes a critical GC safety bug in Blob.FromStream where managed array memory could be moved by the garbage collector after the blob was created, potentially causing crashes. The fix replaces the managed memory approach (using a byte array with fixed) with unmanaged memory allocation using Marshal.AllocCoTaskMem, ensuring the memory location remains stable throughout the blob's lifetime.

Changes:

  • Replaced managed memory pattern (MemoryStream + ToArray + fixed) with unmanaged memory allocation pattern (Marshal.AllocCoTaskMem + UnmanagedMemoryStream)
  • Updated the existing test to use separate using statements for better readability
  • Added a new GC safety test that explicitly verifies the blob data remains accessible after garbage collection

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

File Description
binding/HarfBuzzSharp/Blob.cs Replaced GC-unsafe managed memory with unmanaged memory allocation using Marshal.AllocCoTaskMem/FreeCoTaskMem
tests/Tests/HarfBuzzSharp/HBBlobTest.cs Refactored existing test and added new GC safety test that forces collection and verifies data access

Comment thread tests/Tests/HarfBuzzSharp/HBBlobTest.cs Outdated
Comment thread binding/HarfBuzzSharp/Blob.cs
Comment thread binding/HarfBuzzSharp/Blob.cs
Comment thread binding/HarfBuzzSharp/Blob.cs
Comment thread binding/HarfBuzzSharp/Blob.cs Outdated
Comment thread binding/HarfBuzzSharp/Blob.cs

@mattleibow mattleibow left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: Make Blob.FromStream GC Safe (#3473)

Hey @jeremy-visionaid! Thanks for catching this GC safety issue — it's a subtle but serious bug that could cause random crashes. The core fix (using unmanaged memory instead of a pinned managed array) is the right approach. 👍

I have a couple of concerns before this can be merged:


🔴 Non-seekable streams will throw

The new implementation accesses stream.Length and stream.Position:

var length = (int)(stream.Length - stream.Position);

This throws NotSupportedException on non-seekable streams like NetworkStream, GZipStream, or HTTP response streams. The original implementation handled these correctly by using MemoryStream.CopyTo() which reads until EOF.

Suggested fix:

// For non-seekable streams, buffer into memory first
if (!stream.CanSeek)
{
    using var ms = new MemoryStream ();
    stream.CopyTo (ms);
    ms.Position = 0;
    return FromStream (ms);
}

🔴 Memory leak if an exception occurs

If stream.CopyTo(ums) throws, the allocated memory is never freed:

var dataPtr = Marshal.AllocCoTaskMem (length);
// If CopyTo throws, dataPtr leaks
using var ums = new UnmanagedMemoryStream ((byte*)dataPtr, length, length, FileAccess.ReadWrite);
stream.CopyTo (ums);

Suggested fix: Wrap in try/catch:

var dataPtr = Marshal.AllocCoTaskMem (length);
try
{
    using var ums = new UnmanagedMemoryStream ((byte*)dataPtr, length, length, FileAccess.ReadWrite);
    stream.CopyTo (ums);
    return new Blob (dataPtr, length, MemoryMode.ReadOnly, () => Marshal.FreeCoTaskMem (dataPtr));
}
catch
{
    Marshal.FreeCoTaskMem (dataPtr);
    throw;
}

🟡 Test suggestions

A couple of additional test cases would help ensure edge cases are covered:

Non-seekable stream test:

[SkippableFact]
public void ShouldCreateFromNonSeekableStream()
{
    using var fileStream = File.Open(Path.Combine(PathToFonts, "Funkster.ttf"), FileMode.Open, FileAccess.Read);
    using var nonSeekable = new NonSeekableReadOnlyStream(fileStream);
    using var blob = Blob.FromStream(nonSeekable);
    Assert.Equal(236808, blob.Length);
}

Partially-read stream test:

[SkippableFact]
public void ShouldCreateFromPartiallyReadStream()
{
    using var stream = File.Open(Path.Combine(PathToFonts, "Funkster.ttf"), FileMode.Open, FileAccess.Read);
    
    // Read first 100 bytes, simulating a partially consumed stream
    var header = new byte[100];
    stream.Read(header, 0, 100);
    
    using var blob = Blob.FromStream(stream);
    Assert.Equal(236808 - 100, blob.Length);
}

Complete suggested implementation

Putting it all together:

public static unsafe Blob FromStream (Stream stream)
{
    if (stream == null)
        throw new ArgumentNullException (nameof (stream));

    // For non-seekable streams, buffer into memory first
    if (!stream.CanSeek)
    {
        using var ms = new MemoryStream ();
        stream.CopyTo (ms);
        ms.Position = 0;
        return FromStream (ms);
    }

    var length = (int)(stream.Length - stream.Position);
    if (length == 0)
        return Empty;

    var dataPtr = Marshal.AllocCoTaskMem (length);
    try
    {
        using var ums = new UnmanagedMemoryStream ((byte*)dataPtr, length, length, FileAccess.ReadWrite);
        stream.CopyTo (ums);
        return new Blob (dataPtr, length, MemoryMode.ReadOnly, () => Marshal.FreeCoTaskMem (dataPtr));
    }
    catch
    {
        Marshal.FreeCoTaskMem (dataPtr);
        throw;
    }
}

Thanks again for the contribution! Let me know if you have any questions about the suggested changes.

@github-project-automation github-project-automation Bot moved this to Changes Requested in SkiaSharp Backlog Jan 29, 2026
mattleibow added a commit that referenced this pull request Feb 13, 2026
mattleibow added a commit that referenced this pull request Jul 3, 2026
Expand the skill's scan taxonomy from 6 to 12 families, each backed by a
real historical SkiaSharp leak fix (finalizer/collection ordering #3796/#3291,
Clone double-free #2904, disposing native statics #1863/#4080/#1224,
field-not-nulled #1256/#1344, stream/callback/delegate-proxy lifetime
#3589/#2916/#996, allocation-failure #1784/#1642). Broaden Phase 1.3 de-dup to
search by api/type name (real leaks are filed as [BUG], not [memory-leak]) and
add the Blob.FromStream / PR #3473 worked example. Extend the Phase 3.2 fix
table to cover all 12 families. Document two verified, un-filed family-6
candidates surfaced by a model-diverse scan (SKRegion.SpanIterator missing
parent ref; SKPixmap.ExtractSubset/With* not propagating pixelSource).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
mattleibow added a commit that referenced this pull request Jul 3, 2026
Audit of all 11 families for over-specific/live targets that would let
the agent copy an answer instead of investigating:

- Family 4 (fixed-pointer): replace the "real, still-unfixed
  Blob.FromStream" worked example with a synthetic non-copying-native
  example; demote Blob.FromStream to a bare #3472/PR #3473 citation.
  Same for the SKILL.md cheat-sheet row 4.
- Family 3 (Views): drop the embedded live SKGLElement / open-PR #3311
  diagnosis from "Real cases"; keep the issue numbers only.
- Family 6 (clone double-free): genericize the SKPaint.Clone example to
  a neutral SKThing/sk_thing_clone wrapper; keep #2904 citation.

Remaining real symbols are all safe: documented same-instance contracts
(family 2), already-fixed issue citations, or correct reference
implementations to mirror (families 7-10). No un-fixed target is handed
to the agent as a worked answer.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Changes Requested

Development

Successfully merging this pull request may close these issues.

[BUG] Blob FromStream is not GC safe

3 participants